<?php

/**
 * Browscap.ini parsing class with caching and update capabilities
 *
 * PHP version 5
 *
 * LICENSE: This library is free software; you can redistribute it and/or
 * modify it under the terms of the GNU Lesser General Public
 * License as published by the Free Software Foundation; either
 * version 2.1 of the License, or (at your option) any later version.
 *
 * This library is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
 * Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public
 * License along with this library; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 *
 * This is the offline extension of Browscap, with features such as auto updates.
 *
 * @package    Browscap
 * @author     Jonathan Stoppani <st.jonathan@gmail.com>
 * @copyright  Copyright (c) 2006-2008 Jonathan Stoppani
 * @version    0.7
 * @license    http://www.gnu.org/licenses/lgpl.html GNU Lesser General Public License
 * @link       http://garetjax.info/projects/browscap/
 */

include_once '../Browscap.php';

class BrowscapFull extends Browscap
{

	/**
	 * Different ways to access remote and local files.
	 *
	 * UPDATE_FOPEN:     Uses the fopen url wrapper (use file_get_contents).
	 * UPDATE_FSOCKOPEN: Uses the socket functions (fsockopen).
	 * UPDATE_CURL:      Uses the cURL extension.
	 * UPDATE_LOCAL:     Updates from a local file (file_get_contents).
	 */
	const UPDATE_FOPEN      = 'URL-wrapper';
	const UPDATE_FSOCKOPEN  = 'socket';
	const UPDATE_CURL       = 'cURL';
	const UPDATE_LOCAL      = 'local';

	/**
	 * Definitions of the function used by the uasort() function to order the
	 * userAgents array.
	 *
	 * ORDER_FUNC_ARGS:  Arguments that the function will take.
	 * ORDER_FUNC_LOGIC: Internal logic of the function.
	 */
	const ORDER_FUNC_ARGS   = '$a, $b';
	const ORDER_FUNC_LOGIC  = '$a=strlen($a);$b=strlen($b);return$a==$b?0:($a<$b?1:-1);';

	/**
	 * The headers to be sent for checking the version and requesting the file.
	 */
	const REQUEST_HEADERS   = "GET %s HTTP/1.0\r\nHost: %s\r\nUser-Agent: %s\r\nConnection: Close\r\n\r\n";

	/**
	 * The values to quote in the ini file
	 */
	const VALUES_TO_QUOTE   = 'Browser|Parent';
	/**
	 * Options for auto update capabilities
	 *
	 * $remoteVerUrl:   The location to use to check out if a new version of the
	 *                  browscap.ini file is available.
	 * $remoteIniUrl:   The location from which download the ini file.
	 *                  The placeholder for the file should be represented by a %s.
	 * $timeout:    The timeout for the requests.
	 * $updateInterval: The update interval in seconds.
	 * $errorInterval:  The next update interval in seconds in case of an error.
	 * $doAutoUpdate:   Flag to disable the automatic interval based update.
	 * $updateMethod:  The method to use to update the file, has to be a value of
	 *                  an UPDATE_* constant, null or false.
	 */
	public $remoteIniUrl    = 'http://browsers.garykeith.com/stream.asp?BrowsCapINI';
	public $remoteVerUrl    = 'http://browsers.garykeith.com/versions/version-date.asp';
	public $timeout         = 5;
	public $updateInterval  = 432000; // 5 days
	public $errorInterval   = 7200;   // 2 hours
	public $doAutoUpdate    = true;
	public $updateMethod    = null;

	/**
	 * The path of the local version of the browscap.ini file from which to
	 * update (to be set only if used).
	 *
	 * @var string
	 */
	public $localFile       = null;

	/**
	 * The useragent to include in the requests made by the class during the
	 * update process.
	 *
	 * @var string
	 */
	public $userAgent       = 'PHP Browser Capabilities Project/%v %m';

	/**
	 * Flag to enable/disable silent error management.
	 * In case of an error during the update process the class returns an empty
	 * array/object if the update process can't take place and the browscap.ini
	 * file does not exist.
	 *
	 * @var bool
	 */
	public $silent          = false;

	/**
	 * Where to store the downloaded ini file.
	 *
	 * @var string
	 */
	public $iniFilename     = 'browscap.ini';

	/* all the filters that have been applied.
	 * all the browsers that are not in this filter
	 * will be generated for an empty filter which
	 * implies "everything else"
	 */
	protected $applied_filter  = array();

	/**
	 * Check if we can update cache.
	 * @throws Browscap_Exception if not in silent mode
	 * @return array if in silent mode
	 */
	protected function checkCache() {
		$cache_file = $this->cacheDir . $this->cacheFilenames[0];
		$ini_file  = $this->cacheDir . $this->iniFilename;

		// Set the interval only if needed
		if ($this->doAutoUpdate && file_exists($ini_file)) {
			$interval = time() - filemtime($ini_file);
		} else {
			$interval = 0;
		}

		// Find out if the cache needs to be updated
		if (!file_exists($cache_file) || !file_exists($ini_file) || ($interval > $this->updateInterval)) {
			try {
				$this->updateCache();
			} catch (Browscap_Exception $e) {
				if (file_exists($ini_file)) {
					// Adjust the filemtime to the $errorInterval
					touch($ini_file, time() - $this->updateInterval + $this->errorInterval);
				} else if ($this->silent) {
					// Return an array if silent mode is active and the ini db doesn't exsist
					return array();
				}

				if (!$this->silent) {
					throw $e;
				}
			}
		}
	}

	/**
	 * Parses the ini file and updates the cache files
	 *
	 * @return bool whether the file was correctly written to the disk
	 */
	public function updateCache()
	{
		$ini_path       = $this->cacheDir . $this->iniFilename;

		// Choose the right url
		if ($this->_getUpdateMethod() == self::UPDATE_LOCAL) {
			$url = $this->localFile;
		} else {
			$url = $this->remoteIniUrl;
		}

		$this->_getRemoteIniFile($url, $ini_path);

		$browsers       = parse_ini_file($ini_path, true);
		array_shift($browsers);

		$this->_properties  = array_keys($browsers['DefaultProperties']);
		array_unshift(
			$this->_properties,
			'browser_name',
			'browser_name_regex',
			'browser_name_pattern',
			'Parent'
		);

		$this->_userAgents   = array_keys($browsers);
		usort(
			$this->_userAgents,
			create_function(self::ORDER_FUNC_ARGS, self::ORDER_FUNC_LOGIC)
		);

		$user_agents_keys   = array_flip($this->_userAgents);
		$properties_keys  = array_flip($this->_properties);

		$search        = array('\*', '\?');
		$replace      = array('.*', '.');

		foreach ($this->_userAgents as $user_agent) {
			$pattern = preg_quote($user_agent, self::REGEX_DELIMITER);
			$this->_patterns[]   = self::REGEX_DELIMITER
									. '^'
									. str_replace($search, $replace, $pattern)
									. '$'
									. self::REGEX_DELIMITER;

			if (!empty($browsers[$user_agent]['Parent'])) {
				$parent = $browsers[$user_agent]['Parent'];
				$browsers[$user_agent]['Parent'] = $user_agents_keys[$parent];
			}

			foreach ($browsers[$user_agent] as $key => $value) {
				$key = $properties_keys[$key];
				$browser[$key] = $value;
			}

			$this->_browsers[] = $browser;
			unset($browser);
		}
		unset($user_agents_keys, $properties_keys, $browsers);

		// Save the keys lowercased if needed
		if ($this->lowercase) {
			$this->_properties = array_map('strtolower', $this->_properties);
		}

		$result = true;
		require $this->cacheDir . $this->cacheLevelFile;

		foreach ($cache_level as $filename => $browsers) {
			$cache = $this->_buildCache($browsers);

			// Save and return
			$cache_path      = $this->cacheDir . $filename;
			$result = $result
								&& (bool) file_put_contents($cache_path, $cache, LOCK_EX);
		}
		return $result;
	}

	/**
	 * Parses the array to cache and creates the PHP string to write to disk
	 *
	 * @param array browsers to be cached
	 * @return string the PHP string to save into the cache file
	 */
	private function _buildCache($filter = array())
	{
		$everything_else = false;

		if (empty($filter)) {
			// everything else that we have not cached
			$everything_else = true;
		} else {
			$this->applied_filter = array_merge($this->applied_filter, $filter);
		}
		$patterns = array();
		$browsers = array();
		$user_agents = array();

		// apply filter
		$browser_field_id = end(array_keys($this->_properties, 'Browser'));
		$major_field_id = end(array_keys($this->_properties, 'MajorVer'));
		$parent_field_id = end(array_keys($this->_properties, 'Parent'));

		foreach ($this->_patterns as $k => $pattern) {
			if (empty($this->_browsers[$k])
					||
					!empty($browsers[$k])) {
				continue;
			}
			$browser = $this->constructBrowser('dumb', $k, $pattern);
			$browser_id = $browser[$browser_field_id] . $browser[$major_field_id];
			$should_cache = false;
			if ($everything_else) {
				$should_cache =
					array_search($browser_id, $this->applied_filter) === FALSE;
			} else {
				$should_cache = array_search($browser_id, $filter) !== FALSE;
			}
			if ($should_cache) {
				$key = $k;
				while ($key !== null) {
					$int_key = $key;
					$key = null;
					if (!empty($this->_patterns[$int_key])) {
						$patterns[$int_key] = $this->_patterns[$int_key];
					}
					if (!empty($this->_userAgents[$int_key])) {
						$user_agents[$int_key] = $this->_userAgents[$int_key];
					}
					if (!empty($this->_browsers[$int_key]) &&
							!isset($browsers[$int_key])) {
						$browsers[$int_key] = $this->_browsers[$int_key];
						if (!empty($browsers[$int_key][$parent_field_id])) {
							$key = $browsers[$int_key][$parent_field_id];
						}
					}
				}
			}
		}


		$cacheTpl = "<?php\n\$properties=%s;\n\$browsers=%s;\n\$userAgents=%s;\n\$patterns=%s;\n";
		$propertiesArray  = $this->_array2string($this->_properties);
		$patternsArray     = $this->_array2string($patterns);
		$userAgentsArray  = $this->_array2string($user_agents);
		$browsersArray    = $this->_array2string($browsers);

		return sprintf(
			$cacheTpl,
			$propertiesArray,
			$browsersArray,
			$userAgentsArray,
			$patternsArray
		);
		return sprintf(
				$cacheTpl,
				$propertiesArray,
				var_export($browsers, true),
				var_export($user_agents, true),
				var_export($patterns, true)
				);
	}

	/**
	 * Updates the local copy of the ini file (by version checking) and adapts
	 * his syntax to the PHP ini parser
	 *
	 * @param string $url  the url of the remote server
	 * @param string $path the path of the ini file to update
	 * @throws Browscap_Exception
	 * @return bool if the ini file was updated
	 */
	private function _getRemoteIniFile($url, $path)
	{
		// Check version
		if (file_exists($path) && filesize($path)) {
			$local_tmstp   = filemtime($path);

			if ($this->_getUpdateMethod() == self::UPDATE_LOCAL) {
				$remote_tmstp = $this->_getLocalMTime();
			} else {
				$remote_tmstp = $this->_getRemoteMTime();
			}

			if ($remote_tmstp < $local_tmstp) {
				// No update needed, return
				touch($path);
				return false;
			}
		}

		// Get updated .ini file
		$browscap = $this->_getRemoteData($url);


		$browscap = explode("\n", $browscap);

		$pattern = self::REGEX_DELIMITER
				 . '('
				 . self::VALUES_TO_QUOTE
				 . ')="?([^"]*)"?$'
				 . self::REGEX_DELIMITER;


		// Ok, lets read the file
		$content = '';
		foreach ($browscap as $subject) {
			$subject = trim($subject);
			$content .= preg_replace($pattern, '$1="$2"', $subject) . "\n";
		}

		if (!file_put_contents($path, $content)) {
			throw new Browscap_Exception("Could not write .ini content to $path");
		}

		return true;
	}

	/**
	 * Gets the remote ini file update timestamp
	 *
	 * @throws Browscap_Exception
	 * @return int the remote modification timestamp
	 */
	private function _getRemoteMTime()
	{
		$remote_datetime = $this->_getRemoteData($this->remoteVerUrl);
		$remote_tmstp = strtotime($remote_datetime);

		if (!$remote_tmstp) {
			throw new Browscap_Exception("Bad datetime format from {$this->remoteVerUrl}");
		}

		return $remote_tmstp;
	}

	/**
	 * Gets the local ini file update timestamp
	 *
	 * @throws Browscap_Exception
	 * @return int the local modification timestamp
	 */
	private function _getLocalMTime()
	{
		if (!is_readable($this->localFile) || !is_file($this->localFile)) {
			throw new Browscap_Exception("Local file is not readable");
		}

		return filemtime($this->localFile);
	}


	private function isArrayNumeric($array) {
		$expected = 0;
		foreach ($array as $key => $v) {
			if ($expected != $key ||
					!ctype_digit((string) $key)) {
				return false;
			}
			$expected ++;
		}
		return true;
	}

	/**
	 * Converts the given array to the PHP string which represent it.
	 * This method optimizes the PHP code and the output differs form the
	 * var_export one as the internal PHP function does not strip whitespace or
	 * convert strings to numbers.
	 *
	 * @param array $array the array to parse and convert
	 * @return string the array parsed into a PHP string
	 */
	private function _array2string($array)
	{
		$strings = array();
		$numeric = $this->isArrayNumeric($array);

		foreach ($array as $key => $value) {
			if ($numeric) {
				$key  = '';
			} else if (ctype_digit((string) $key)) {
				$key   = intval($key) . '=>' ;
			} else {
				$key   = "'" . str_replace("'", "\'", $key) . "'=>" ;
			}

			if (is_array($value)) {
				$value  = $this->_array2string($value);
			} else if (ctype_digit((string) $value)) {
				$value   = intval($value);
			} else {
				$value   = "'" . str_replace("'", "\'", $value) . "'";
			}

			$strings[]  = $key . $value;
		}

		return 'array(' . implode(',', $strings) . ')';
	}

	/**
	 * Checks for the various possibilities offered by the current configuration
	 * of PHP to retrieve external HTTP data
	 *
	 * @return string the name of function to use to retrieve the file
	 */
	private function _getUpdateMethod()
	{
		// Caches the result
		if ($this->updateMethod === null) {
			if ($this->localFile !== null) {
				$this->updateMethod = self::UPDATE_LOCAL;
			} else if (ini_get('allow_url_fopen') && function_exists('file_get_contents')) {
				$this->updateMethod = self::UPDATE_FOPEN;
			} else if (function_exists('fsockopen')) {
				$this->updateMethod = self::UPDATE_FSOCKOPEN;
			} else if (extension_loaded('curl')) {
				$this->updateMethod = self::UPDATE_CURL;
			} else {
				$this->updateMethod = false;
			}
		}

		return $this->updateMethod;
	}

	/**
	 * Retrieve the data identified by the URL
	 *
	 * @param string $url the url of the data
	 * @throws Browscap_Exception
	 * @return string the retrieved data
	 */
	private function _getRemoteData($url)
	{
		switch ($this->_getUpdateMethod()) {
			case self::UPDATE_LOCAL:
				$file = file_get_contents($url);

				if ($file !== false) {
					return $file;
				} else {
					throw new Browscap_Exception('Cannot open the local file');
				}
			case self::UPDATE_FOPEN:
				$file = file_get_contents($url);

				if ($file !== false) {
					return $file;
				} // else try with the next possibility (break omitted)
			case self::UPDATE_FSOCKOPEN:
				$remote_url    = parse_url($url);
				$remote_handler = fsockopen($remote_url['host'], 80, $c, $e, $this->timeout);

				if ($remote_handler) {
					stream_set_timeout($remote_handler, $this->timeout);

					if (isset($remote_url['query'])) {
						$remote_url['path'] .= '?' . $remote_url['query'];
					}

					$out = sprintf(
						self::REQUEST_HEADERS,
						$remote_url['path'],
						$remote_url['host'],
						$this->_getUserAgent()
					);

					fwrite($remote_handler, $out);

					$response = fgets($remote_handler);
					if (strpos($response, '200 OK') !== false) {
						$file = '';
						while (!feof($remote_handler)) {
							$file .= fgets($remote_handler);
						}

						$file = str_replace("\r\n", "\n", $file);
						$file = explode("\n\n", $file);
						array_shift($file);

						$file = implode("\n\n", $file);

						fclose($remote_handler);

						return $file;
					}
				} // else try with the next possibility
			case self::UPDATE_CURL:
				$ch = curl_init($url);

				curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
				curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $this->timeout);
				curl_setopt($ch, CURLOPT_USERAGENT, $this->_getUserAgent());

				$file = curl_exec($ch);

				curl_close($ch);

				if ($file !== false) {
					return $file;
				} // else try with the next possibility
			case false:
				throw new Browscap_Exception('Your server can\'t connect to external resources. Please update the file manually.');
		}
	}

	/**
	 * Format the useragent string to be used in the remote requests made by the
	 * class during the update process.
	 *
	 * @return string the formatted user agent
	 */
	private function _getUserAgent()
	{
		$ua = str_replace('%v', self::VERSION, $this->userAgent);
		$ua = str_replace('%m', $this->_getUpdateMethod(), $ua);

		return $ua;
	}
}


